agentmux_launcher\saga\log/schema.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// LSD-1 — launcher saga log schema migration.
5//
6// See `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.2 for
7// the canonical schema. Mirrors srv's `run_saga_log_migrations` in
8// `agentmux-srv/src/backend/storage/migrations.rs` but with two
9// launcher-specific deltas:
10//
11// 1. A `target` column on the step table. Launcher sagas dispatch
12// to multiple peers (self / host / srv); srv sagas only ever
13// target the srv reducer so srv's schema can omit it.
14// 2. A `failed_compensation` saga state. Launcher sagas don't
15// auto-compensate (LSD spec §3.5); recovery marks unresolved
16// sagas as `failed_compensation` for operator review. Srv has
17// a separate `compensated` terminal state instead.
18//
19// Schema lifecycle policy (LSD spec §5 risk #2): only additive changes
20// via `ALTER TABLE` in future migration versions. No in-place rewrites.
21
22use rusqlite::Connection;
23
24use super::LogError;
25
26/// DDL applied on every `LauncherSagaLog::open()`. Idempotent:
27/// `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` make
28/// reopening the same DB a no-op. Schema mirrors LSD spec §3.2 verbatim
29/// (timestamps as RFC3339 TEXT — easier to grep in SQLite shells than
30/// epoch ms; PR LSD-2's coordinator wiring serializes via
31/// `chrono::DateTime<Utc>::to_rfc3339`).
32pub(super) const DDL: &str = "
33CREATE TABLE IF NOT EXISTS launcher_saga (
34 saga_id INTEGER PRIMARY KEY,
35 name TEXT NOT NULL,
36 state TEXT NOT NULL CHECK (state IN ('running', 'completed', 'failed', 'compensating', 'failed_compensation')),
37 started_at TEXT NOT NULL,
38 ended_at TEXT,
39 input_json TEXT NOT NULL,
40 failure_reason TEXT
41);
42
43CREATE TABLE IF NOT EXISTS launcher_saga_step (
44 saga_id INTEGER NOT NULL REFERENCES launcher_saga(saga_id) ON DELETE CASCADE,
45 step_index INTEGER NOT NULL,
46 name TEXT NOT NULL,
47 state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed', 'compensated')),
48 cmd_json TEXT,
49 target TEXT,
50 started_at TEXT NOT NULL,
51 ended_at TEXT,
52 output_json TEXT,
53 failure_reason TEXT,
54 PRIMARY KEY (saga_id, step_index)
55);
56
57CREATE INDEX IF NOT EXISTS idx_launcher_saga_state
58 ON launcher_saga(state);
59CREATE INDEX IF NOT EXISTS idx_launcher_saga_step_state
60 ON launcher_saga_step(saga_id, state);
61";
62
63/// Apply `DDL` to a fresh or existing connection.
64pub(super) fn run_migrations(conn: &Connection) -> Result<(), LogError> {
65 conn.execute_batch(DDL)?;
66 Ok(())
67}
68
69/// `user_version` value stamped into `launcher-sagas.db`. Mirrors srv's
70/// `stamp_and_check_version` tripwire (AUDIT_SQLITE_SYSTEMS §8.5). The
71/// launcher is a separate crate from srv, so the helper is duplicated
72/// here rather than shared.
73pub(super) const LAUNCHER_SAGA_SCHEMA_VERSION: i64 = 1;
74
75/// Read `PRAGMA user_version`; warn loudly if the file was written by a
76/// newer launcher build (downgrade tripwire), then stamp the current
77/// version. The idempotent DDL above remains the schema mechanism — this
78/// only records the version.
79pub(super) fn stamp_and_check_version(conn: &Connection) -> Result<(), LogError> {
80 let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
81 if found > LAUNCHER_SAGA_SCHEMA_VERSION {
82 eprintln!(
83 "[launcher-saga-log] launcher-sagas.db user_version={found} > \
84 {LAUNCHER_SAGA_SCHEMA_VERSION} — written by a newer launcher \
85 build; proceeding read-compatible"
86 );
87 }
88 conn.execute_batch(&format!(
89 "PRAGMA user_version = {LAUNCHER_SAGA_SCHEMA_VERSION};"
90 ))?;
91 Ok(())
92}